Repository navigation
Conversation
falcondpr
force-pushed
the
feat/accessible-sheet-dismissal
branch
from
July 8, 2026 18:11
9429916 to
8decf8f
Compare
iOS: expose the scrim to VoiceOver as a dismiss button while a non-programmatic close detent exists, and implement accessibilityPerformEscape on the hosting view so the escape gesture dismisses the sheet through the exact scrim-tap path. Android: expose the canvas-drawn scrim as a virtual dismiss button via ExploreByTouchHelper, and surface ACTION_DISMISS on the sheet container so TalkBack's dismiss action closes the sheet from focus within the content. Refs software-mansion-labs#52
Replace the canvas-drawn scrim and its virtual accessibility node with a real native View placed below the sheet container. ExploreByTouchHelper could not reliably combine the virtual scrim with the host's real descendants. Its accessibility provider exposed the scrim while making sheet content unreachable in parts of the TalkBack tree. Forwarding key events from the host to the helper also allowed it to consume Enter before the event reached a focused sheet descendant, including when no modal scrim was active. Using a real child gives the scrim standard Android accessibility, focus, keyboard, and traversal behavior. Expose it as a Dismiss button, limit its accessibility bounds to the area above the sheet, and place it after the sheet container in TalkBack traversal order. Route click, dismiss, Enter, Space, and DPAD Center through the existing scrim dismissal path so they emit onIndexChange without triggering onCloseRequest. Keep the scrim visually full-screen and animate only View.alpha. Change visibility and accessibility properties only when their effective state changes, and use INVISIBLE instead of GONE to avoid extra layout work. The host continues to own touch handling, while the scrim view always rejects touch events, preserving the existing gesture state machine. Keep React Native child counts and Fabric indices scoped to the sheet container despite the additional native child. Remove the host ExploreByTouchHelper integration, manual Canvas drawing, Paint state, and the direct androidx.customview dependency. Add regression coverage for the real accessibility tree, focused content key delivery, scrim semantics and bounds, confirm-key activation, touch routing, disabled states, and separation from Back/Escape close requests.
Portal close ownership previously followed registration order within each React root. That could route Back or Escape to a sheet below another active portal and allowed roots in the same host window to make conflicting ownership decisions. Add a window-scoped presentation coordinator that tracks active portals, resolves their current React roots and native hierarchy paths, and selects a unique top presentation from observable drawing order. Reconcile ownership before drawing so z-order changes take effect without requiring sheet state or layout updates. When visual order cannot be proven, keep registration order as a deterministic close fallback without treating it as visual top. Centralize presentation ownership so close-request routing and portal accessibility can consume the same Active and Top presentation decisions instead of deriving potentially conflicting owners independently. Move active-presentation tracking out of close-request code so presentation lifecycle and ordering are independent of handlers and input policy. Close routing now consumes the shared assignment while preserving closing-through-settle ownership, handlerless top blocking, synchronous owner transfer, and predictive Back and Escape capture semantics. Add coverage for multiple React roots in one window, independent windows, native drawing order, unknown order, hierarchy migration, stale entries, and observable Back and Escape routing.
Verify that the sheet-owned accessibility action commits the closed detent without emitting a close request when focus is inside sheet content.
DanyKrk
force-pushed
the
feat/accessible-sheet-dismissal
branch
from
September 20, 2026 10:29
8decf8f to
59d8dad
Compare
Mask branches outside coordinator-selected React root paths and restore application importance safely. Reconcile Fabric and Paper mount batches, preserve multiple retained paths, and cover the behavior with focused JVM tests.
Mount portal wrappers under one non-flattened native host so Android can derive a unique visual Top even when the provider is nested below a custom ViewGroup. This prevents the conservative unknown-order fallback from leaving lower active portals reachable to TalkBack. Add provider-topology and real-view accessibility regressions, and run the JS coverage in CI.
Keep a two-portal screen with distinct lower and upper focus targets plus open, closing, and settle logs. This gives maintainers a repeatable TalkBack path for verifying that the visual Top hides the lower portal until ownership transfers after settle.
DanyKrk
force-pushed
the
feat/accessible-sheet-dismissal
branch
from
September 21, 2026 18:51
0996735 to
a18c33a
Compare
…-dismissal # Conflicts: # android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt # ios/BottomSheetComponentView.mm # src/BottomSheetProvider.tsx
Author
|
@DanyKrk heads-up: this PR had conflicts with Three files had conflicts:
Checks:
Let me know if you'd have resolved any of these differently. |
This was
linked to
issues
Sep 24, 2026
Collaborator
|
@falcondpr Thanks for merging main into the PR—I’d keep all three conflict resolutions as-is, and the later CI run also passed all 35 iOS native tests. |
…into feat/accessible-sheet-dismissal # Conflicts: # ios/BottomSheetComponentView.mm
…' into feat/accessible-sheet-dismissal
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #52.
Problem
ModalBottomSheethas no accessible dismissal path:UIControlthat is never exposed to VoiceOver — sighted users can tap it to close the sheet, but VoiceOver users cannot reach it.accessibilityPerformEscapeis not implemented on the host.drawScrim), so it does not exist in the accessibility tree at all, and noACTION_DISMISSis surfaced — TalkBack has no dismiss affordance either.Change
iOS (
BottomSheetHostingView.swift)scrimDismissIndex. A scrim over a programmatic-only close detent stays out of the tree, since it is decorative rather than actionable. Exposure is recomputed inupdateInteractionState(), alongside the existingisUserInteractionEnabledlogic.accessibilityPerformEscapeis implemented on the hosting view. It reuses the exact scrim-tap dismissal path — factored intoattemptScrimDismissal(), shared withhandleScrimPress— and returnsfalsewhen there is nothing to dismiss, so the gesture keeps bubbling to enclosing containers.UIControlsubclass whoseaccessibilityActivatesends.touchUpInsidedirectly. VoiceOver's default activation simulates a tap at the activation point, which is unreliable when the sheet overlaps that point mid-settle.Android (
BottomSheetHostView.kt)ExploreByTouchHelper: button class, translatable "Dismiss" content description, bounds spanning from the host's top edge down to the sheet's current top, existing only under the same only-while-dismissible condition as iOS. Hover, key, and focus events are forwarded so both touch exploration and keyboard navigation reach it.ACTION_DISMISSis surfaced on the sheet container so TalkBack's dismiss action works while focus is inside the sheet content — the Android counterpart ofaccessibilityPerformEscape. Both paths funnel intoattemptScrimDismissal(), which mirrors the scrim-tap path (scrimDismissIndex→snapToIndex) and emitsonIndexChange, so a controlledindexstays in sync.invalidateRoot()only on transitions; the check lives inupdateInteractionState(), which runs on every frame of a settle.androidx.customview:customview(forExploreByTouchHelper).No JS or public-API changes.
Validation
Example app on React Native 0.85.3 (New Architecture, Hermes).
Android — physical Pixel 9:
uiautomator dump) contains the virtual scrim node:class="android.widget.Button",content-desc="Dismiss", clickable, focusable, bounds[0,0][1080,1689]— from the top of the screen down to the sheet's top edge.iOS — iPhone 17 simulator (iOS 26.4):
Notes